Skip to content

Serve input_required handlers on 2025-era connections via a legacy fulfilment shim#2381

Merged
felixweinberger merged 15 commits into
mainfrom
fweinberger/mrtr-shim
Jun 29, 2026
Merged

Serve input_required handlers on 2025-era connections via a legacy fulfilment shim#2381
felixweinberger merged 15 commits into
mainfrom
fweinberger/mrtr-shim

Conversation

@felixweinberger

Copy link
Copy Markdown
Contributor

Serve input_required handlers on 2025-era connections: a default-on legacy fulfilment shim at the server seam converts each embedded request of an input_required return into a real server→client request (elicitation/create, sampling/createMessage, roots/list) over the live session and re-enters the handler with the collected inputResponses until a final result. Handlers are written once in the 2026 style and serve both eras.

Motivation and Context

Today a handler that wants to serve both protocol eras has to implement every interactive conversation twice: an awaited push-style arm for 2025-era connections and an input_required state machine for 2026-07-28 — and the SDK fails loudly (-32603) if the 2026-style return reaches a 2025-era request. This PR makes the input_required form the single way to write interactive handlers:

  • The shim (on by default; ServerOptions.inputRequired.legacyShim: false restores the loud failure) mirrors the client auto-fulfilment driver exactly so a handler cannot tell which era fulfilled it: per-round REPLACED inputResponses, byte-exact requestState echo with the configured verify hook running every round against the round's own context, paced requestState-only rounds, and a round cap (inputRequired.maxRounds, default 8) sharing the driver's accounting and message. Elicitation accepted content passes through unvalidated, exactly as the modern driver does, so the handler's recovery path (schema-aware acceptedContent → re-ask) behaves identically per era.
  • Legs ride the existing senders with stream association (relatedRequestId) and an explicit human-paced timeout (inputRequired.roundTimeoutMs, default 600s) plus a live resetTimeoutOnProgress (legs carry a progressToken, so a client reporting progress mid-leg extends the leg). URL-mode legs synthesize the elicitationId the 2025-11-25 wire requires (CSPRNG-backed). One synthetic progress tick per completed round (only when the originating request carried a progressToken) stays monotonic above any handler-emitted progress on the same token.
  • The shim's own capability pre-check (not gated on enforceStrictCapabilities) reads the per-request resolved capability view: capability-less clients and stateless per-request legacy serving (no initialize, no server→client channel) get a clean typed refusal before any wire traffic — never a hang. Failures surface per family: isError tool results for tools/call, JSON-RPC errors for prompts/get / resources/read; server bugs (malformed input-required results) fail loudly on both eras.
  • Typed requestState: ctx.mcpReq.requestState becomes an accessor — ctx.mcpReq.requestState<T>() returns the verify hook's decoded payload (createRequestStateCodec.verify), the raw wire string with no hook, or undefined. The hook's resolved value is now load-bearing (documented); codec users read verified state with no second decode call.
  • Typed inputResponses readers from @modelcontextprotocol/server: a schema-aware acceptedContent(responses, key, schema) overload, a discriminated inputResponse(responses, key) view (missing | elicit | sampling | roots), and samplingText(responses, key).

How Has This Been Tested?

  • New unit suites: legacyInputRequiredShim.test.ts (26 tests: happy paths across all three embedded kinds incl. concurrent legs, REPLACE/echo semantics, round-cap exhaustion per family, capability gating incl. the bare-elicitation:{}-means-form rule and the stateless refusal, leg failures, decline pass-through, unvalidated-content recovery, leg timeout 600s vs the 60s default and progress-based reset under fake timers, synthetic progress gating + monotonicity, requestState verify-per-round + typed accessor + the frozen -32602, URL-leg elicitationId synthesis, knob validation) and legacyShimWriteOnce.test.ts (a multi-phase HMAC-codec write-once tool completing its full elicit → custom-count → sampling conversation on a 2025 session).
  • New e2e requirement typescript:mrtr:legacy-shim:write-once-on-2025 running on every stateful arm (stdio, in-memory, sessionful Streamable HTTP, legacy SSE) with a real Client answering the real elicitation/create; a serveStdio entry test; reader unit tests in core-internal.
  • Full local gates green: typecheck, lint, docs:check, core-internal (1305), server (380), client (698), integration (348), e2e (2627 + 155 expected-fail), server conformance baseline, and the examples matrix (65/65 legs).

Breaking Changes

  • ctx.mcpReq.requestState (v2 alpha surface) changes from an optional string property to an always-present typed accessor: reads become ctx.mcpReq.requestState<string>(). Truthiness no longer means "has state", and a configured requestState.verify hook's resolved value now backs the accessor (verifiers that are not decoders should resolve undefined).
  • Behavior change: an input_required return on a 2025-era request is now fulfilled by the shim instead of failing with -32603. The previous behavior is available via ServerOptions.inputRequired.legacyShim: false. Documented in docs/migration/support-2026-07-28.md (new section "Legacy shim for input_required") with a changeset for @modelcontextprotocol/server and @modelcontextprotocol/core-internal.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

  • The conformance fixture (test/conformance/src/everythingServer.ts) is updated to the accessor read; the conformance baseline is unchanged.
  • The previously pinned "2025-era loud failure" test now pins the legacyShim: false escape hatch — the default-on fulfilment is the deliberate behavior change this PR makes.
  • Follow-up candidates (not in this PR): run the examples/mrtr story on dual eras to demo the shim end-to-end (its stateless-HTTP legacy leg needs the documented refusal handling), and simplify examples/elicitation's era branches away.

@felixweinberger felixweinberger requested a review from a team as a code owner June 29, 2026 11:28
@changeset-bot

changeset-bot Bot commented Jun 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d2ec928

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/server Minor
@modelcontextprotocol/core-internal Minor
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/node Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jun 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2381

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2381

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2381

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2381

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2381

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2381

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2381

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2381

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2381

commit: d2ec928

Comment thread packages/server/src/index.ts
…lfilment shim

A tools/call, prompts/get, or resources/read handler that returns an
input_required result on a 2025-era connection is now served by a
default-on shim at the server seam: each embedded request is sent as a
real server-to-client request (elicitation/create,
sampling/createMessage, roots/list) over the live session, stamped with
the originating request id for stream association, and the handler is
re-entered with the collected inputResponses until it returns a final
result. Handlers are written once in the input_required style and serve
both eras; ServerOptions.inputRequired.legacyShim: false restores the
previous loud -32603 failure.

Round semantics mirror the client auto-fulfilment driver: per-round
replaced inputResponses, byte-exact requestState echo with the verify
hook running every round against the round's own context, paced
requestState-only rounds, shared round-cap accounting (default 8), and
elicitation accepted content passed through unvalidated for the handler
to check. Legs carry an explicit human-paced timeout
(inputRequired.roundTimeoutMs, default 600s) with a live
resetTimeoutOnProgress, URL-mode legs synthesize the elicitationId the
2025-11-25 wire requires, and one synthetic progress tick per completed
round (progressToken-gated, monotonic above handler-emitted progress)
keeps watchdog clients alive. Failures surface per family: isError tool
results for tools/call, JSON-RPC errors for prompts/resources. The
shim's own capability pre-check reads the per-request resolved view, so
capability-less clients and stateless per-request legacy serving get a
clean refusal before any wire traffic.

ctx.mcpReq.requestState becomes a typed accessor: requestState<T>()
returns the verify hook's decoded payload (createRequestStateCodec's
verify), the raw wire string without a hook, or undefined. New typed
readers for inputResponses ship from the server package: a schema-aware
acceptedContent overload, a discriminated inputResponse view, and
samplingText.
The discriminated inputResponse() view and the schema-aware
acceptedContent overload carry protocol knowledge (bare response shape
discrimination, the documented validation path for unvalidated accepted
content) and stay public. Text extraction from a sampling response is a
content convenience handlers write themselves — the migration guide now
shows it as a one-liner over the discriminated view instead of shipping
a samplingText export.
RequestStateAccessor is now a named public type (re-exported with the
other protocol context types) instead of an indexed-access incantation,
and the factory's outer cast goes: a generic arrow is directly
assignable to the declared signature. The single remaining 'as T' is
the deliberate caller-asserted typing the accessor documents — no
implementation can produce an arbitrary T from a runtime value.
The driver's onprogress message and the shim's wire progress
notification now come from one formatter, and the migration guide
states why the shim's round cap (8) is tighter than the client
driver's (10). Also restores the auto-generated codemod versions file
this branch had accidentally regenerated.
Comment thread packages/server/src/server/server.ts Outdated
Comment thread packages/server/src/server/server.ts Outdated
Comment thread packages/core-internal/src/shared/inputRequired.ts
Comment thread docs/migration/support-2026-07-28.md Outdated
The reference server's three interactive tools (brainstorm_tasks,
clear_done, prioritize) each carried a hand-written 2025 push-style arm
next to their input_required form. The shim makes the input_required
form serve both eras, so the arms go: brainstorm_tasks keeps only its
requestState phase machine (read via the typed accessor — no second
decode), and clear_done/prioritize keep only their single-round
input_required shape. The cli-client e2e legs exercise the same
conversations on stdio/legacy through the shim unchanged.
@felixweinberger felixweinberger force-pushed the fweinberger/mrtr-shim branch from 9021ba9 to 8126aaa Compare June 29, 2026 13:19
Comment thread .changeset/legacy-input-required-shim.md
The shim no longer emits progress against the originating request's
progressToken. That token is a single must-increase stream owned by
the handler, and a second author cannot compose with it — the previous
floor/suppression machinery managed the conflict instead of removing
it. A client watchdog tight enough to need synthetic liveness already
cannot complete an interactive flow on the 2025 era (push-style legs
emit nothing either), and neither sibling SDK bridge emits synthetic
progress. Handlers that report progress across rounds derive values
from their phase state, which increases across re-entries.

Also: the inputRequired module header now describes the shim default
instead of the pre-shim loud failure; the migration guide's sampling
one-liner narrows the content block type before reading .text, and its
JSON-mode note states the real behavior (undeliverable legs wait out
roundTimeoutMs — the transport drops server-to-client requests in that
mode, as it does for elicitInput today).
Both changesets ship in the same version bump; the older one still
promised a loud failure for input_required returns toward 2025-era
requests, which the shim now serves by default.
Server no longer carries the fulfilment loop inline: it lazily holds
one LegacyInputRequiredShim (legacyInputRequiredShim.ts) constructed
against a narrow host contract — knobs, the resolved capability view,
the requestState verify runner, and the three capability-check-free
sender cores — and the seam delegates to it in one line. The shared
embedded-request validation moves with it (the modern capability check
imports it back), withRequestStateValue moves to core-internal next to
requestStateAccessor, and server.ts shrinks by ~300 lines. Behavior
unchanged; package-internal, not exported from the index.
Comment thread .changeset/legacy-input-required-shim.md
The shim module now owns its knob defaults and validation
(resolveLegacyShimOptions, mirroring the client driver's config
resolver), and the sampling sender split is reverted: public
createMessage works as-is for shim legs (the era assert is a no-op on
legacy and the tools-capability check is guaranteed redundant behind
the shim's gate), so only the elicitation core keeps a split — the one
place the public checks genuinely differ from the gate.
The shim module, the server seam, and the requestState docs carried
prose that restated the code or repeated the module header per method.
Comments now state only what the code cannot: the parity contract, the
security notes, and the per-family/gating rules — once each.
…-shim

Resolves the one conflict in support-2026-07-28.md: keeps the shim's
'no branch needed' row in the freshly reformatted MRTR table.
…fy return

Same class as the MRTR seam changeset fix: it ships in the same version
bump and still described the hook's return as discarded.
Comment thread examples/todos-server/todos.ts
The README still sent readers to compare era branches the previous
commit deleted, and the stateCodec JSDoc still taught the retired
second-verify decode pattern instead of the typed accessor.
Comment thread packages/server/src/server/server.ts
Comment thread packages/server/src/index.ts
felixweinberger and others added 2 commits June 29, 2026 15:29
It still said input-required returns are only legal toward the
2026-07-28 era and that capability violations always answer -32021 —
the body of this very method now fulfils 2025-era returns through the
shim, whose gate surfaces violations per family.
@felixweinberger felixweinberger merged commit f0bf785 into main Jun 29, 2026
18 checks passed
@felixweinberger felixweinberger deleted the fweinberger/mrtr-shim branch June 29, 2026 15:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant